package com.uxsino.simo.indicator.retractor;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.NoSuchElementException;

import com.uxsino.simo.networkentity.EntityInfo;
import com.uxsino.simo.query.QueryTemplate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.uxsino.simo.query.QueryContext;

/**
 * This deals with retracting a list of values from a JSON string in the
 * following format {@code {"AA": [{"BB": v1 ...}, {"BB": v2 ...}] ...}} Only
 * the simplest case as above is currently considered. The result is a list of
 * values "v1", "v2", etc ... <br />
 * the XML should have
 * {@code <retract indicator="..." parser="json_string_array">} and the columns
 * should look like {@code <column col_id="AA->BB" field="..."/> } <br />
 * 
 * It works now for {@code <column col_id="AA/BB->{CC/DD:=:dd}/EE/FF->GG/HH" field="..."/> }
 * The result for the above snippet should be the values for the key HH in
 * {AA:{BB:[{CC:{DD: dd, EE:{FF:[{GG:{HH:hh1, ...}}, {GG:{HH:hh2, ...}}]}},}, ...]}}
 * where only those corresponding to {DD:dd} are taken, i.e. hh1, hh2.
 * 
 * Should be able to adapt and work for {@code {"AA":{"BB":[{"CC": {"DD": v1,
 * "EE": w1}, {"CC": {"DD": v2, "EE": w2} ...}} ...]}}} and get list of values
 * as "(v1, w1 ...)" and "(v2, w2 ...)"
 * 
 * The method checks the next level for extraction subkey of type {@code "{CC/DD:=:dd}/EE/FF"}.
 * If such key is in the next level, the following happens:
 * 1. Convert the {@link JSONArray} obtained in this level into {@link JSONObject}, with keys
 * given by the value of "CC/DD"; and replace the {@link JSONArray} by the converted {@link JSONObject}.
 * 2. Suppose that the current subkey is "AA/BB", then replace the current subkey by "AA/BB/dd/EE/FF",
 * delete the subkey {@code "{CC/DD:=:dd}/EE/FF"} in the next level -- in effect shortens 
 * the {@code extractionKey}; then run the recursion at the same {@code level}. This process won't 
 * go on forever since the total number of levels is reduced by 1 in this step.
 * 3. The corresponding keys in {@code fieldMapper} and {@code retractorMapper} is also changed by 
 * the rules in step 2.
 * 4. The step 2 may cause the length of extractionKey to be less than 2, thus an extra block is added
 * to deal with this change -- which does NOT render checking {@code extractionKey.length} in 
 * {@link fieldRecursion} method obsolete.
 * 
 * 
 *
 */
public class JSONStringArrayRetractor extends ListValueRetractor {

    private static Logger logger = LoggerFactory.getLogger(JSONStringArrayRetractor.class);

    /**
     * The next six constants conforms to the conventions in the corresponding
     * .xml files for the indicators and queries.
     */
    private static final String SELF_FULL = "1";

    private static final String ARRAY_SEP = "->";

    private static final String FIELD_SEP = "/";

    private static final String CONVERT_START = "{";

    private static final String CONVERT_START_EXP = "\\{";

    private static final String CONVERT_END = "}";

    private static final String CONVERT_END_EXP = "\\}";

    private static final String VALUE_MARK = ":=:";

    private static final String PLACE_HOLDER = "*";

    Map<String, String> fieldMapper = new HashMap<>();

    Map<String, IValueRetractor> retractorMapper = new HashMap<>();

    List<String> convertedTypes = new ArrayList<>();

    List<String> indexList = new ArrayList<>();

    @Override
    public Object doRetract(EntityInfo entity, QueryContext ctxt, QueryTemplate qt, Object obj) {

        if (null == obj) {
            logger.error("JSON string array passed into retractor is null.");
            return null;
        }

        JSONObject jsonObj = JSON.parseObject((String) obj);

        for (ColumnEntry entry : columnEntries) {
            String entryKey = entry.index;
            if (conversionRequired(entryKey)) {
                String indexType = extractType(entryKey);
                if (!convertedTypes.contains(indexType)) {
                    convert(indexType, jsonObj);
                    convertedTypes.add(indexType);
                }
                entryKey = reduce(entryKey);
                // logger.info("------------ reduce entry.index to: {}", entryKey);
            }
            indexList.add(entryKey);
            fieldMapper.put(entryKey, entry.field.getName());
            retractorMapper.put(entryKey, entry.retractor);
        }

        // logger.info("------------------ before entering filter, jsonObj is {}", jsonObj);
        JSONObject resultObj = filterRecursion(jsonObj);

        JSONArray resultArr = splitArray(resultObj);

        List<Map<String, Object>> result = new ArrayList<>();
        for (Object splitObj : resultArr) {
            // turn each splitObj into a Map of retracted values
            Map<String, Object> resultValues = new HashMap<>();
            for (Map.Entry<String, Object> entry : ((JSONObject) splitObj).entrySet()) {
                String mappedKey = fieldMapper.get(entry.getKey());
                Object retractedObj = retractorMapper.get(entry.getKey()).retract(entity, ctxt, qt, entry.getValue());
                resultValues.put(mappedKey, retractedObj);
            }
            result.add(resultValues);
        }

        // clean up so that next invocation does not get confused.
        fieldMapper.clear();
        retractorMapper.clear();
        convertedTypes.clear();
        indexList.clear();

        return result;
    }

    /**
    * Verify if the extraction involves generating an intermediate {@link JSONObject}
    * using values in the input {@link JSONString}.
    *
    * @param extractionKey
    * @return {@code true} if the {@code extractionKey} obtained from the .xml files
    * describing the query should contain a line similar to
    * {@code column col_id="AA/BB->{CC/DD:=:dd}/EE/FF" field="..."/>}
    * where {@code bb} may show up as
    * {@code {AA:{BB: bb, ...},..., CC:{DD:[{EE: ee...}, ...]...}, ...}}
    * and the extracted value for the field in this case is {@code ee}.
    */
    private boolean conversionRequired(String extractionKey) {
        return extractionKey.matches(".*" + CONVERT_START_EXP + ".+" + VALUE_MARK + ".+" + CONVERT_END_EXP + ".*");
    }

    /**
     * If the {@code fieldKey} is of the form {@code "MM/NN->AA/BB->{CC/DD:dd}/EE/FF->{GG/HH:hh}/II/JJ ..."}
     * The type is then {@code "MM/NN->AA/BB->{CC/DD:*}/EE/FF->{GG/HH:*}/II/JJ ..."}
     * 
     * If {@code fieldKey} does not contain the pattern {@code "{..:=:..}"}, then the input the directly returned.
     * 
     * @param fieldKey: a fieldKey that may contain subKey of the form {@code "{CC/DD:dd}/EE/FF"}
     * @return the type of the fieldKey
     */
    private String extractType(String fieldKey) {
        if (!conversionRequired(fieldKey)) {
            return fieldKey;
        }
        String result = "";
        String[] split = fieldKey.split(VALUE_MARK);
        result += split[0];
        for (int i = 1; i < split.length; i++) {
            String[] valueSplit = split[i].split(CONVERT_END_EXP);
            result += VALUE_MARK + PLACE_HOLDER + CONVERT_END + valueSplit[1];
        }
        return result;
    }

    /**
     * Take an output from the {@code extractType} method, e.g.: 
     * {@code "MM/NN->AA/BB->{CC/DD:=:*}/EE/FF->{GG/HH:=:*}/II/JJ ..."}
     * and convert the {@link JSONArray} pointed by the key {@code "MM/NN->AA/BB"}
     * in the {@code jsonInput} to a {@link JSONObject} pointed by {@code "MM/NN->AA/BB"}
     * with keys given by the values pointed by {@code "MM/NN->AA/BB->CC/DD"} and
     * values given by the corresponding object in the {@link JSONArray} pointed by 
     * {@code "MM/NN->AA/BB"}
     * 
     * In short, after this method to get the same data, one needs to use the {@code extractType}
     * {@code "MM/NN->AA/BB/dd/EE/FF->{GG/HH:=:*}/II/JJ ..."}
     * where {@code dd} are the values pointed by {@code "MM/NN->AA/BB->CC/DD"} 
     * 
     * @param fieldKeyType
     * @param jsonInput
     */
    private void convertFirst(String fieldKeyType, JSONObject jsonInput) {
        if (!conversionRequired(fieldKeyType)) {
            return;
        }
        String[] keyType = fieldKeyType.split(ARRAY_SEP);
        if (keyType.length == 1) {
            return;
        }
        JSONObject jsonObj = jsonInput;
        int prev = 0;
        while (!conversionRequired(keyType[prev + 1])) {
            jsonObj = (JSONObject) getForKey(keyType[prev], jsonObj);
            prev++;
        }
        JSONArray jsonArr = (JSONArray) getForKey(keyType[prev], jsonObj);
        if (null == jsonArr) {
            // do nothing for now -- since it should not get into here.
            logger.error("---------- It should not come to this: null array in convertFirst()");
        } else {
            String firstReplace = keyType[prev + 1];
            String newKeyEntry = firstReplace.substring(firstReplace.indexOf(CONVERT_START) + 1,
                firstReplace.indexOf(VALUE_MARK));
            JSONObject replaceObj = arrayToObject(jsonArr, newKeyEntry);
            setForKey(replaceObj, keyType[prev], jsonObj);
        }
    }

    private void convert(String fieldKeyType, JSONObject jsonInput) {
        String keyType = fieldKeyType;
        while (conversionRequired(keyType)) {
            convertFirst(keyType, jsonInput);
            keyType = reduceFirst(keyType);
        }
    }

    /**
     * If the {@code fieldKey} is of the form {@code "AA/BB->{CC/DD:=:dd}/EE/FF->{GG/HH:=:hh}/II/JJ ..."}
     * the result is {@code "AA/BB/dd/EE/FF->{GG/HH:=:hh}/II/JJ ..."}
     * 
     * If {@code fieldKey} is of the form {@code "{CC/DD:=:dd}/EE/FF->{GG/HH:=:hh}/II/JJ ..."}, which should never happen,
     * the result is {@code "dd/EE/FF->{GG/HH:=:hh}/II/JJ ..."}
     * 
     * @param fieldKey
     * @return
     */
    private String reduceFirst(String fieldKey) {
        if (!conversionRequired(fieldKey)) {
            return fieldKey;
        }
        String result = "";
        int firstConvertStartIndex = fieldKey.indexOf(CONVERT_START);
        int firstReplaceIndex = fieldKey.indexOf(VALUE_MARK);
        int firstConvertEndIndex = fieldKey.indexOf(CONVERT_END);
        if (firstConvertStartIndex > 0) {
            // there should always be stuff before the "{", meaning "->{" must show up
            // -- get rid of first "->{", replace it by "/"
            result += fieldKey.substring(0, fieldKey.indexOf(ARRAY_SEP + CONVERT_START)) + FIELD_SEP;
        }
        result += fieldKey.substring(firstReplaceIndex + VALUE_MARK.length(), firstConvertEndIndex);
        result += fieldKey.substring(firstConvertEndIndex + CONVERT_END.length());
        return result;
    }

    private String reduce(String fieldKey) {
        String result = fieldKey;
        while (conversionRequired(result)) {
            result = reduceFirst(result);
        }
        return result;
    }

    /**
     * This turns a {@link JSONArray} with {@link JSONObject} entries into a 
     * {@link JSONObject} with the value for the {@code keyField} as the new 
     * keys, and the corresponding entry in the array as the respective values.
     * 
     * Assumption: the values pointed by the {@code keyField} are all distinct
     * 
     * @param arrayInput: The input {@link JSONArray}.
     * @param keyField: Of the form "AA/BB", and the value of this field will be the new keys.
     * @return the converted {@link JSONObject}. {@code null} if the {@code arrayInput} is {@code null}.
     * @throws NoSuchElementException if the {@code keyField} is not found in the entries.
     */
    private JSONObject arrayToObject(JSONArray arrayInput, String keyField) throws NoSuchElementException {
        if (null == arrayInput) {
            return null;
        }
        JSONObject resultObject = new JSONObject();
        for (Object arrayEntry : arrayInput) {
            Object newKey = getForKey(keyField, ((JSONObject) arrayEntry));
            if (null == newKey) {
                throw new NoSuchElementException(
                    "The required key " + keyField + " in entries of " + arrayInput + " does not exist.");
            }
            resultObject.put((String) newKey, ((JSONObject) arrayEntry));
        }
        return resultObject;
    }

    /**
     * The {@code keyName} is of the format "AA/BB/CC", corresponding to
     * "{AA:{BB:{CC:...}}}" in the {@code jsonObj}
     * 
     * @param subKeyName
     * @param jsonObj
     * @return The {@link Object} pointed to by the key.
     */
    private Object getForKey(String keyName, JSONObject jsonObj) {
        if (null == jsonObj) {
            return null;
        }
        Object jsonResult = null;

        if (keyName.contains(FIELD_SEP)) {
            String[] jsonSubFlds = keyName.split(FIELD_SEP);
            JSONObject tmpObj = jsonObj;
            for (int i = 0; i < jsonSubFlds.length - 1; i++) {
                tmpObj = tmpObj.getJSONObject(jsonSubFlds[i]);
                if (null == tmpObj) {
                    return null;
                }
            }
            jsonResult = tmpObj.get(jsonSubFlds[jsonSubFlds.length - 1]);
        } else {
            jsonResult = jsonObj.get(keyName);
        }
        return jsonResult;
    }

    /**
     * The {@code keyName} is of the format "AA/BB/CC", corresponding to
     * "{AA:{BB:{CC:...}}}" in the {@code jsonObj}
     * 
     * Set the value pointed by {@code keyName} in {@code jsonObj} to the {@code newValue}
     * 
     * @param newValue
     * @param subKeyName
     * @param jsonObj
     */
    private void setForKey(Object newValue, String keyName, JSONObject jsonObj) {
        if (null == jsonObj) {
            return;
        }

        if (keyName.contains(FIELD_SEP)) {
            String[] jsonSubFlds = keyName.split(FIELD_SEP);
            JSONObject tmpObj = jsonObj;
            for (int i = 0; i < jsonSubFlds.length - 1; i++) {
                // only go to the next to last level,
                // the last level is where newValue is added
                if (null == tmpObj.getJSONObject(jsonSubFlds[i])) {
                    JSONObject newObj = new JSONObject();
                    // this supposes that the implementation of
                    tmpObj.put(jsonSubFlds[i], newObj.fluentPut(jsonSubFlds[i + 1], null));
                }
                tmpObj = tmpObj.getJSONObject(jsonSubFlds[i]);
            }
            tmpObj.put(jsonSubFlds[jsonSubFlds.length - 1], newValue);
        } else {
            jsonObj.put(keyName, newValue);
        }
    }

    /**
     * The {@code jsonInput} is of the form "{AA:[{AA->BB:..., AA->CC: ...},
     * {AA->BB:..., AA->CC: ...}]}". The output is a {@link JSONArray} of the
     * form ["{AA->BB:..., AA->CC: ...}", "{AA->BB:..., AA->CC: ...}"]
     * 
     * Right now, this works for the filtered results in the examples.
     * 
     * TO DO get rid of the assumption about the form of the input. -- DONE, see
     * {@code splitArrayByExtractionKey}
     * 
     * @param jsonString
     * @return
     */
    private JSONArray splitArray(JSONObject jsonInput) {
        if (null == jsonInput) {
            logger.error("Filtered JSON string array should not be null.");
            return null;
        }

        JSONArray arrayInput = new JSONArray().fluentAdd(jsonInput);

        for (ColumnEntry entry : columnEntries) {
            String[] extractionKey = entry.index.split(ARRAY_SEP);
            if (extractionKey.length > 1) {
                arrayInput = splitArrayByExtractionKey(extractionKey, arrayInput, 1);
            }
        }

        return arrayInput;
    }

    /**
     * The input should have been processed already by {@code filterRecursion}
     * method.
     * 
     * Recursively split a {@link JSONArray} {@code jsonInput} according to the
     * {@code extractionKey}, which is an array of keys in the
     * {@code jsonInput}. The format of {@code jsonInput} is [{AA: VA1, BB:
     * [{BB->CC: VC11, BB->DD: [{BB->DD->EE: ... }, {BB->DD->EE: ...}] },
     * {BB->CC: VC12, BB->DD: [{BB->DD->EE: ... }, {BB->DD->EE: ...}] }, ...]},
     * {AA: VA2, BB: [{BB->CC: VC21, BB->DD: [{BB->DD->EE: ... }, {BB->DD->EE:
     * ...}] }, {BB->CC: VC22, BB->DD: [{BB->DD->EE: ... }, {BB->DD->EE: ...}]
     * }, ...]}, ...] Suppose that the {@code extractionKey = [BB, DD, EE]}, and
     * the {@code sepIndex = 1}, then the {@link JSONArray} {@code splitOutput}
     * is of the form [{AA: VA1, BB->CC: VC11, BB->DD: [{BB->DD->EE: ... },
     * {BB->DD->EE:...}]}, {AA: VA1, BB->CC: VC12, BB->DD: [{BB->DD->EE: ... },
     * {BB->DD->EE:...}]}, {AA: VA2, BB->CC: VC21, BB->DD: [{BB->DD->EE: ... },
     * {BB->DD->EE:...}]}, {AA: VA2, BB->CC: VC22, BB->DD: [{BB->DD->EE: ... },
     * {BB->DD->EE:...}]}]
     *
     * The next round of the recursion should take the {@code splitOutput} as
     * the {@code jsonInput}, using the same
     * {@code extractionKey = [BB, DD, EE]}, but with {@code sepIndex = 2}, then
     * it result in {@code splitOutput} becoming [{AA: VA1, BB->CC: VC11,
     * BB->DD->EE: ... }, {AA: VA1, BB->CC: VC11, BB->DD->EE:...}, {AA: VA1,
     * BB->CC: VC12, BB->DD->EE: ... }, {AA: VA1, BB->CC: VC12, BB->DD->EE:...},
     * {AA: VA2, BB->CC: VC21, BB->DD->EE: ... }, {AA: VA2, BB->CC: VC21,
     * BB->DD->EE:...}, {AA: VA2, BB->CC: VC22, BB->DD->EE: ... }, {AA: VA2,
     * BB->CC: VC22, BB->DD->EE:...}]
     * 
     * In this example, the second round of recursion is the last round, since
     * {@code sepIndex + 1 == extractionKey.length} In general, it can keep on
     * going until that condition is met.
     * 
     * The {@code sepIndex} starts with "1".
     * 
     * @param extractionKey
     * @param jsonInput
     * @param sepIndex
     * 
     * @return {@link JSONArray} {@code splitOutput}
     */
    private JSONArray splitArrayByExtractionKey(String[] extractionKey, JSONArray jsonInput, int sepIndex) {

        if (sepIndex >= extractionKey.length) {
            return jsonInput;
        }

        String arrayKey = extractionKey[0];
        for (int i = 1; i < sepIndex; i++) {
            arrayKey += (ARRAY_SEP + extractionKey[i]);
        }

        JSONArray nextLevel = new JSONArray();

        for (Object obj : jsonInput) {
            JSONObject individualObj = (JSONObject) ((JSONObject) obj).clone();
            JSONArray targetArray = individualObj.getJSONArray(arrayKey);
            if (null != targetArray) {
                // the array has not been split yet.
                individualObj.remove(arrayKey);
                for (Object insertion : targetArray) {
                    JSONObject insertionResult = (JSONObject) individualObj.clone();
                    insertionResult.putAll((JSONObject) insertion);
                    nextLevel.add(insertionResult);
                }
            } else {
                // the array has been split, do nothing and pass it along
                nextLevel.add(individualObj);
            }
        }

        return splitArrayByExtractionKey(extractionKey, nextLevel, sepIndex + 1);
    }

    /**
     * Filter the input {@code jsonObj} using the {@code columnEntries}. This
     * deals with "AA/BB->CC/DD->EE/FF" by recursion calling
     * {@code filterRecursion}.
     * 
     * When the {@link ColumnEntry} has index {@code ARRAY_SEP}, the complete
     * original {@code jsonInput} is added to the output with key
     * {@code ARRAY_SEP}.
     * 
     * @param jsonObj
     * @return
     */
    private JSONObject filterRecursion(JSONObject jsonInput) {

        if (null == jsonInput) {
            logger.error("JSON string array passed into filterRecursion is null.");
            return null;
        }
        JSONObject filteredObject = new JSONObject();

        for (String index : indexList) {
            String[] extractionKey = index.split(ARRAY_SEP);
            if (extractionKey.length > 1) {
                filterByExtractionKey(extractionKey, jsonInput, filteredObject, 1);
            } else {
                if (index.equals(SELF_FULL)) { // brute force, should be
                                               // made more universal
                    filteredObject.put(index,
                        new JSONArray().fluentAdd(new JSONObject().fluentPut(SELF_FULL, jsonInput.toString())));
                } else {
                    filteredObject.put(index, getForKey(index, jsonInput));
                }
            }
        }
        return filteredObject;
    }

    /**
     * A recursive version of filtering by the {@code extractionKey}, which is
     * of the form "[AA/BB,CC/DD,EE/FF]", where "AA/BB" has index 0 in the
     * array.
     * 
     * The {@code level} starts at 1, denoting resolving AA/BB. Each recursion
     * increases the {@code level} by 1. The recursion exits when the
     * {@code level} equals to the length of the {@code extractionKey}
     * 
     * The {@code extractionKey} must contain at least two entries.
     * 
     * @param extractionKey
     * @param jsonInput
     * @param jsonOutput
     * @param level
     */
    private void filterByExtractionKey(String[] extractionKey, JSONObject jsonInput, JSONObject filteredOutput,
        int level) {

        String prefix = "";
        for (int i = 0; i < level - 1; i++) {
            prefix += (extractionKey[i] + ARRAY_SEP);
        }
        if (extractionKey.length > level) {

            String subKeyName = extractionKey[level - 1];

            JSONArray jsonArr = (JSONArray) getForKey(subKeyName, jsonInput);

            JSONArray filteredArray = filteredOutput.getJSONArray(subKeyName);
            if (null == filteredArray) {
                int arraySize = null == jsonArr ? 1 : jsonArr.size();
                filteredOutput.put(subKeyName, new JSONArray(arraySize));
                filteredArray = filteredOutput.getJSONArray(subKeyName);
                for (int j = 0; j < arraySize; j++) {
                    filteredArray.add(new JSONObject());
                }
            }
            ListIterator<Object> filteredIterator = filteredArray.listIterator();

            if (null == jsonArr) {
                // nothing is found for the subkey from the jsonInput
                // should fill up the structure till the final key, and put down value null
                if (extractionKey.length == level + 1) {
                    String newKey = prefix + subKeyName + ARRAY_SEP + extractionKey[level];
                    ((JSONObject) filteredIterator.next()).put(newKey, null);
                } else {
                    JSONObject nextLevel = new JSONObject();
                    filterByExtractionKey(extractionKey, null, nextLevel, level + 1);
                    ((JSONObject) filteredIterator.next()).put(prefix + subKeyName + ARRAY_SEP + extractionKey[level],
                        nextLevel.get(extractionKey[level]));
                }
                return;
            }

            // now there is something to filter
            for (Object object : jsonArr) {
                if (extractionKey.length == level + 1) {
                    String newKey = prefix + subKeyName + ARRAY_SEP + extractionKey[level];
                    Object newObject = getForKey(extractionKey[level], (JSONObject) object);
                    ((JSONObject) filteredIterator.next()).put(newKey, newObject);
                    // ((JSONObject) filteredIterator.next()).put(prefix +
                    // subKeyName + ARRAY_SEP + extractionKey[level],
                    // getForKey(extractionKey[level], (JSONObject) object));
                } else {
                    JSONObject nextLevel = new JSONObject();
                    filterByExtractionKey(extractionKey, (JSONObject) object, nextLevel, level + 1);
                    ((JSONObject) filteredIterator.next()).put(prefix + subKeyName + ARRAY_SEP + extractionKey[level],
                        nextLevel.get(extractionKey[level]));
                }
            }
        }
    }
}
